msg_tool\scripts\ex_hibit\arc/
grp.rs

1//! ExHibit GRP archive extractor.
2use crate::ext::io::*;
3use crate::scripts::base::*;
4use crate::types::*;
5use anyhow::{Context, Result};
6use std::fmt::Debug;
7use std::io::{Read, Seek, SeekFrom};
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10
11#[derive(Debug)]
12/// Builder for ExHibit GRP archives.
13pub struct ExHibitGrpArchiveBuilder {}
14
15impl ExHibitGrpArchiveBuilder {
16    /// Creates a new builder instance.
17    pub const fn new() -> Self {
18        Self {}
19    }
20
21    fn build_with_reader<T>(
22        &self,
23        reader: T,
24        filename: &str,
25        archive_encoding: Encoding,
26        config: &ExtraConfig,
27    ) -> Result<Box<dyn Script>>
28    where
29        T: Read + Seek + Debug + 'static,
30    {
31        Ok(Box::new(ExHibitGrpArchive::new(
32            reader,
33            filename,
34            archive_encoding,
35            config,
36        )?))
37    }
38}
39
40impl ScriptBuilder for ExHibitGrpArchiveBuilder {
41    fn default_encoding(&self) -> Encoding {
42        Encoding::Cp932
43    }
44
45    fn default_archive_encoding(&self) -> Option<Encoding> {
46        Some(Encoding::Cp932)
47    }
48
49    fn build_script(
50        &self,
51        data: Vec<u8>,
52        filename: &str,
53        _encoding: Encoding,
54        archive_encoding: Encoding,
55        config: &ExtraConfig,
56        _archive: Option<&Box<dyn Script>>,
57    ) -> Result<Box<dyn Script>> {
58        self.build_with_reader(MemReader::new(data), filename, archive_encoding, config)
59    }
60
61    fn build_script_from_file(
62        &self,
63        filename: &str,
64        _encoding: Encoding,
65        archive_encoding: Encoding,
66        config: &ExtraConfig,
67        _archive: Option<&Box<dyn Script>>,
68    ) -> Result<Box<dyn Script>> {
69        if filename == "-" {
70            return Err(anyhow::anyhow!(
71                "Reading ExHibit GRP from stdin is not supported; provide a file path."
72            ));
73        }
74        let file = std::fs::File::open(filename)
75            .with_context(|| format!("Failed to open '{}'.", filename))?;
76        let reader = std::io::BufReader::new(file);
77        self.build_with_reader(reader, filename, archive_encoding, config)
78    }
79
80    fn build_script_from_reader(
81        &self,
82        reader: Box<dyn ReadSeek>,
83        filename: &str,
84        _encoding: Encoding,
85        archive_encoding: Encoding,
86        config: &ExtraConfig,
87        _archive: Option<&Box<dyn Script>>,
88    ) -> Result<Box<dyn Script>> {
89        self.build_with_reader(reader, filename, archive_encoding, config)
90    }
91
92    fn extensions(&self) -> &'static [&'static str] {
93        &["grp"]
94    }
95
96    fn script_type(&self) -> &'static ScriptType {
97        &ScriptType::ExHibitGrp
98    }
99
100    fn is_this_format(&self, filename: &str, buf: &[u8], buf_len: usize) -> Option<u8> {
101        if !matches_grp_name(filename) {
102            return None;
103        }
104        if buf_len >= 4 && buf.starts_with(b"AiFS") {
105            return None;
106        }
107        Some(10)
108    }
109
110    fn is_archive(&self) -> bool {
111        true
112    }
113}
114
115#[derive(Clone, Debug)]
116struct GrpFileEntry {
117    name: String,
118    offset: u64,
119    size: u64,
120}
121
122#[derive(Debug)]
123/// ExHibit GRP archive instance.
124pub struct ExHibitGrpArchive<T: Read + Seek + Debug> {
125    reader: Arc<Mutex<T>>,
126    entries: Vec<GrpFileEntry>,
127}
128
129impl<T: Read + Seek + Debug> ExHibitGrpArchive<T> {
130    fn new(
131        mut reader: T,
132        filename: &str,
133        _archive_encoding: Encoding,
134        _config: &ExtraConfig,
135    ) -> Result<Self> {
136        let mut header = [0u8; 4];
137        reader
138            .peek_exact_at(0, &mut header)
139            .context("Failed to read GRP header.")?;
140        if &header == b"AiFS" {
141            return Err(anyhow::anyhow!(
142                "Input file is a TOC (AiFS) rather than an archive."
143            ));
144        }
145
146        let path = Path::new(filename);
147        let (toc_path, arc_index) = locate_toc_file(path).context("Failed to locate TOC file.")?;
148
149        let archive_size = (&mut reader)
150            .stream_length()
151            .context("Failed to determine archive size.")?;
152
153        let entries = parse_toc_entries(&toc_path, arc_index, archive_size)
154            .with_context(|| format!("Failed to parse TOC '{}'.", toc_path.display()))?;
155
156        Ok(Self {
157            reader: Arc::new(Mutex::new(reader)),
158            entries,
159        })
160    }
161}
162
163impl<T: Read + Seek + Debug + 'static> Script for ExHibitGrpArchive<T> {
164    fn default_output_script_type(&self) -> OutputScriptType {
165        OutputScriptType::Json
166    }
167
168    fn default_format_type(&self) -> FormatOptions {
169        FormatOptions::None
170    }
171
172    fn is_archive(&self) -> bool {
173        true
174    }
175
176    fn iter_archive_filename<'a>(
177        &'a self,
178    ) -> Result<Box<dyn Iterator<Item = Result<String>> + 'a>> {
179        Ok(Box::new(
180            self.entries.iter().map(|entry| Ok(entry.name.clone())),
181        ))
182    }
183
184    fn iter_archive_offset<'a>(&'a self) -> Result<Box<dyn Iterator<Item = Result<u64>> + 'a>> {
185        Ok(Box::new(self.entries.iter().map(|entry| Ok(entry.offset))))
186    }
187
188    fn open_file<'a>(&'a self, index: usize) -> Result<Box<dyn ArchiveContent + 'a>> {
189        if index >= self.entries.len() {
190            return Err(anyhow::anyhow!(
191                "Index out of bounds: {} (max: {}).",
192                index,
193                self.entries.len()
194            ));
195        }
196        let entry = self.entries[index].clone();
197        Ok(Box::new(GrpEntry::new(entry, self.reader.clone())))
198    }
199}
200
201struct GrpEntry<T: Read + Seek> {
202    info: GrpFileEntry,
203    reader: Arc<Mutex<T>>,
204    pos: u64,
205}
206
207impl<T: Read + Seek> GrpEntry<T> {
208    fn new(info: GrpFileEntry, reader: Arc<Mutex<T>>) -> Self {
209        Self {
210            info,
211            reader,
212            pos: 0,
213        }
214    }
215
216    fn remaining(&self) -> u64 {
217        self.info.size.saturating_sub(self.pos)
218    }
219}
220
221impl<T: Read + Seek> ArchiveContent for GrpEntry<T> {
222    fn name(&self) -> &str {
223        &self.info.name
224    }
225}
226
227impl<T: Read + Seek> Read for GrpEntry<T> {
228    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
229        if buf.is_empty() || self.pos >= self.info.size {
230            return Ok(0);
231        }
232        let remaining = self.remaining() as usize;
233        if remaining == 0 {
234            return Ok(0);
235        }
236        let to_read = buf.len().min(remaining);
237        let mut reader = self.reader.lock().map_err(|e| {
238            std::io::Error::new(
239                std::io::ErrorKind::Other,
240                format!("Failed to lock reader mutex: {}", e),
241            )
242        })?;
243        reader.seek(SeekFrom::Start(self.info.offset + self.pos))?;
244        let bytes = reader.read(&mut buf[..to_read])?;
245        self.pos = self.pos.checked_add(bytes as u64).ok_or_else(|| {
246            std::io::Error::new(std::io::ErrorKind::Other, "Read position overflow.")
247        })?;
248        Ok(bytes)
249    }
250}
251
252impl<T: Read + Seek> Seek for GrpEntry<T> {
253    fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
254        let new_pos = match pos {
255            SeekFrom::Start(offset) => offset,
256            SeekFrom::End(offset) => {
257                let signed = self.info.size as i128 + offset as i128;
258                if signed < 0 {
259                    return Err(std::io::Error::new(
260                        std::io::ErrorKind::InvalidInput,
261                        "Seek before entry start is not allowed.",
262                    ));
263                }
264                signed as u64
265            }
266            SeekFrom::Current(offset) => {
267                let signed = self.pos as i128 + offset as i128;
268                if signed < 0 {
269                    return Err(std::io::Error::new(
270                        std::io::ErrorKind::InvalidInput,
271                        "Seek before entry start is not allowed.",
272                    ));
273                }
274                signed as u64
275            }
276        };
277        if new_pos > self.info.size {
278            return Err(std::io::Error::new(
279                std::io::ErrorKind::InvalidInput,
280                "Seek beyond entry size is not allowed.",
281            ));
282        }
283        self.pos = new_pos;
284        Ok(self.pos)
285    }
286}
287
288#[derive(Debug)]
289struct NameInfo {
290    digits_offset: usize,
291    digits_len: usize,
292    arc_num: u32,
293}
294
295fn matches_grp_name(filename: &str) -> bool {
296    Path::new(filename)
297        .file_name()
298        .and_then(|name| name.to_str())
299        .and_then(|name| parse_name_info(name).ok())
300        .is_some()
301}
302
303fn parse_name_info(name: &str) -> Result<NameInfo> {
304    if name.len() < 7 {
305        return Err(anyhow::anyhow!(
306            "Filename '{}' is too short for GRP pattern.",
307            name
308        ));
309    }
310    let prefix = &name[..3];
311    if !prefix.eq_ignore_ascii_case("res") {
312        return Err(anyhow::anyhow!(
313            "Filename '{}' does not start with 'res'.",
314            name
315        ));
316    }
317    let suffix = &name[name.len() - 4..];
318    if !suffix.eq_ignore_ascii_case(".grp") {
319        return Err(anyhow::anyhow!(
320            "Filename '{}' does not end with '.grp'.",
321            name
322        ));
323    }
324    let digits = &name[3..name.len() - 4];
325    if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
326        return Err(anyhow::anyhow!(
327            "Filename '{}' does not contain a numeric sequence.",
328            name
329        ));
330    }
331    let arc_num = digits.parse::<u32>().with_context(|| {
332        format!(
333            "Failed to parse archive number from '{}' (digits '{}').",
334            name, digits
335        )
336    })?;
337    Ok(NameInfo {
338        digits_offset: 3,
339        digits_len: digits.len(),
340        arc_num,
341    })
342}
343
344fn locate_toc_file(path: &Path) -> Result<(PathBuf, u32)> {
345    let file_name = path
346        .file_name()
347        .and_then(|name| name.to_str())
348        .ok_or_else(|| anyhow::anyhow!("Filename contains invalid UTF-8."))?;
349    let info = parse_name_info(file_name)?;
350    if info.arc_num == 0 {
351        return Err(anyhow::anyhow!(
352            "Archive '{}' has number 0 and therefore no preceding TOC file.",
353            file_name
354        ));
355    }
356
357    let mut toc_num = info.arc_num as i64 - 1;
358    let mut arc_index: u32 = 1;
359    while toc_num >= 0 {
360        let digits = format!("{:0width$}", toc_num, width = info.digits_len);
361        let mut candidate = String::with_capacity(file_name.len());
362        candidate.push_str(&file_name[..info.digits_offset]);
363        candidate.push_str(&digits);
364        candidate.push_str(&file_name[info.digits_offset + info.digits_len..]);
365        let candidate_path = path.with_file_name(&candidate);
366        if !candidate_path.exists() {
367            return Err(anyhow::anyhow!(
368                "TOC file '{}' does not exist.",
369                candidate_path.display()
370            ));
371        }
372        let mut file = std::fs::File::open(&candidate_path).with_context(|| {
373            format!(
374                "Failed to open TOC candidate '{}'.",
375                candidate_path.display()
376            )
377        })?;
378        let mut header = [0u8; 4];
379        file.read_exact(&mut header).with_context(|| {
380            format!("Failed to read header from '{}'.", candidate_path.display())
381        })?;
382        if &header == b"AiFS" {
383            return Ok((candidate_path, arc_index));
384        }
385        toc_num -= 1;
386        arc_index = arc_index
387            .checked_add(1)
388            .ok_or_else(|| anyhow::anyhow!("Archive index overflow while searching TOC."))?;
389    }
390
391    Err(anyhow::anyhow!(
392        "Unable to locate a TOC (AiFS) file for '{}'.",
393        file_name
394    ))
395}
396
397fn parse_toc_entries(
398    toc_path: &Path,
399    arc_index: u32,
400    archive_size: u64,
401) -> Result<Vec<GrpFileEntry>> {
402    let file = std::fs::File::open(toc_path)?;
403    let mut reader = std::io::BufReader::new(file);
404    let toc_len = reader.stream_length()?;
405    if toc_len < 0x10 {
406        return Err(anyhow::anyhow!("TOC file is too small."));
407    }
408
409    reader.seek(SeekFrom::Start(0xC))?;
410    let res_count = reader.read_i32()?;
411    if res_count <= 0 {
412        return Err(anyhow::anyhow!("TOC resource count is invalid."));
413    }
414    if arc_index as i64 > res_count as i64 {
415        return Err(anyhow::anyhow!(
416            "Archive index {} is out of range (resource count {}).",
417            arc_index,
418            res_count
419        ));
420    }
421
422    let mut index_offset = 0x10u64;
423    let mut arc_offset = None;
424    for _ in 0..res_count {
425        if index_offset + 0x10 > toc_len {
426            break;
427        }
428        reader.seek(SeekFrom::Start(index_offset))?;
429        let mut num = reader.read_i32()?;
430        if num == 0x0100_0000 {
431            index_offset = index_offset
432                .checked_add(4)
433                .ok_or_else(|| anyhow::anyhow!("Index offset overflow."))?;
434            if index_offset + 4 > toc_len {
435                break;
436            }
437            reader.seek(SeekFrom::Start(index_offset))?;
438            num = reader.read_i32()?;
439        }
440        reader.seek(SeekFrom::Start(index_offset + 0xC))?;
441        let entry_count = reader.read_u32()?;
442        if num == arc_index as i32 {
443            arc_offset = Some(index_offset);
444            break;
445        }
446        let step = (entry_count as u64)
447            .checked_mul(8)
448            .and_then(|v| v.checked_add(0x10))
449            .ok_or_else(|| anyhow::anyhow!("Index offset overflow while skipping entries."))?;
450        index_offset = index_offset
451            .checked_add(step)
452            .ok_or_else(|| anyhow::anyhow!("Index offset overflow while iterating."))?;
453    }
454
455    let arc_offset =
456        arc_offset.ok_or_else(|| anyhow::anyhow!("Archive reference not found in TOC."))?;
457
458    reader.seek(SeekFrom::Start(arc_offset + 4))?;
459    let start_index = reader.read_i32()?;
460    if start_index < 0 {
461        return Err(anyhow::anyhow!("Start index is negative."));
462    }
463    reader.seek(SeekFrom::Start(arc_offset + 0xC))?;
464    let entry_count = reader.read_i32()?;
465    if entry_count < 0 {
466        return Err(anyhow::anyhow!("Entry count is negative."));
467    }
468    let entry_count = entry_count as u32;
469
470    let data_offset = arc_offset
471        .checked_add(0x10)
472        .ok_or_else(|| anyhow::anyhow!("Entry table offset overflow."))?;
473    let table_len = (entry_count as u64)
474        .checked_mul(8)
475        .ok_or_else(|| anyhow::anyhow!("Entry table size overflow."))?;
476    if data_offset + table_len > toc_len {
477        return Err(anyhow::anyhow!("TOC entry table exceeds file size."));
478    }
479
480    let mut entries = Vec::with_capacity(entry_count as usize);
481    let mut entry_offset = data_offset;
482    for i in 0..entry_count {
483        reader.seek(SeekFrom::Start(entry_offset))?;
484        let offset = reader.read_u32()? as u64;
485        let size = reader.read_u32()? as u64;
486        if size != 0 {
487            let end = offset
488                .checked_add(size)
489                .ok_or_else(|| anyhow::anyhow!("Entry size overflow."))?;
490            if end > archive_size {
491                return Err(anyhow::anyhow!(
492                    "Entry {} exceeds archive size (offset {}, size {}).",
493                    i,
494                    offset,
495                    size
496                ));
497            }
498            let index = (start_index as u32)
499                .checked_add(i)
500                .ok_or_else(|| anyhow::anyhow!("Entry index overflow."))?;
501            entries.push(GrpFileEntry {
502                name: format!("{:05}.ogg", index),
503                offset,
504                size,
505            });
506        }
507        entry_offset += 8;
508    }
509
510    if entries.is_empty() {
511        return Err(anyhow::anyhow!("Archive contains no entries."));
512    }
513
514    Ok(entries)
515}